Expressでパスワード再発行機能をつくる(ダウンロード可)

こんにちは。フリーランス・コンサルタント&エンジニアの 九保すこひ です。

さてさて、前回記事「Expressでユーザー登録機能」でも少し触れましたが、ユーザー登録機能をつくる際に「パスワード再発行機能」、いわゆる「パスワード・リマインダー」も同時に開発しようと考えていました。

ただ、ユーザー登録機能が予想を上回るコード量になってしまい、あえなく別記事にするべきと判断しました。

そのため、今回はそれを受けて、

パスワードの再発行機能をExpressでつくる

をテーマにしてお届けします。

ぜひみなさんのお役に立てると嬉しいです😊✨

開発環境: Node 8、Express 4.1

手順

今回開発する「パスワード再発行機能」は以下の手順で実行する機能になります。

  1. ユーザーがメールアドレスを送信
  2. パスワード再発行メールを送信
  3. メールに書かれているURLをクリック
  4. 表示されたフォームから新しいパスワードを送信
  5. 新パスワードを保存する

なお、前回もそうですがこの流れに加えてコードはLaravelを参考したものです。Laravelさん、いつもありがとうございます。m_ _m

パッケージをインストールする

必要になるパッケージですが、ほぼ前回記事と同じになりますので、「前提として」を参考にして以下のパッケージをインストールしておいてください。

  • Sequelize
  • nodemailer
  • express-validator
  • bcrypt

なお、テンプレート・エンジンにはmustache-expressを使っています。

準備する

では、パスワード再発行に必要なDBテーブル「PasswordResets」をつくります。以下のコマンドを実行してください。

npx sequelize-cli model:generate --name PasswordReset --attributes email:string,token:string

すると、以下の2ファイルが作成されます。

  • /models/passwordreset.js
  • /migrations/************-create-password-reset.js

マイグレーションには、不要な項目が含まれているので以下のように変更してください。

'use strict';
module.exports = {
  up: (queryInterface, Sequelize) => {
    return queryInterface.createTable('PasswordResets', {
      email: {
        type: Sequelize.STRING
      },
      token: {
        type: Sequelize.STRING
      },
      createdAt: {
        allowNull: false,
        type: Sequelize.DATE
      }
    });
  },
  down: (queryInterface, Sequelize) => {
    return queryInterface.dropTable('PasswordResets');
  }
};

また、モデルも同様に変更してください。(プライマリーキーの変更や、リレーションシップを追加しています)

'use strict';
module.exports = (sequelize, DataTypes) => {
  const PasswordReset = sequelize.define('PasswordReset', {
    email: {
      type: DataTypes.STRING,
      primaryKey: true
    },
    token: DataTypes.STRING,
    createdAt: DataTypes.DATE
  }, {
    timestamps: false,
  });
  PasswordReset.associate = function(models) {
    PasswordReset.hasOne(models.User, {
      foreignKey: 'email',
      sourceKey: 'email'
    });
  };
  return PasswordReset;
};

では、マイグレーションを実行してDBテーブルを作成します。

npx sequelize-cli db:migrate

すると、テーブルはこのようになります。

必要なパッケージ・モデルを読み込む

app.jsに、以下のパッケージ&モデルを読み込むコードを追加してください。(なお、cryptoはインストール不要で使えるパッケージです)

const { check, validationResult } = require('express-validator');
const nodemailer = require('nodemailer');
const bcrypt = require('bcrypt');
const crypto = require('crypto');
const PasswordReset = require('./models').PasswordReset;
const User = require('./models').User;

ミドルウェアを設定する

同じくapp.jsに、以下のコードを追加してPOSTデータを取得できるようにしておいてください。

app.use(express.json());
app.use(express.urlencoded({ extended: true }));

必要な情報を追加する

ここは前回と重複しますが、暗号化につかうAPP_KEY、トップURLのAPP_URL、そして、メール送信情報のtransporterapp.jsに追加しておきます。(本来は.envを使うべきですが今回は割愛します)

// 暗号化につかうキー
const APP_KEY = 'YOUR-SECRET-KEY';
// トップURL
const APP_URL = 'http://express41.test';
// メール送信設定
const transporter = nodemailer.createTransport({
  host: '127.0.0.1',
  port: 1025,
  secure: '',
  auth: {
    user: '',
    pass: ''
  }
});

メールアドレスを送信するフォームをつくる

では、パスワードを忘れたユーザーが自分のメールアドレスを送信するフォームをつくっていきます。

以下のルートをapp.jsに追加してください。

app.get('/password/reset', (req, res) => {

  res.render('auth/passwords/email');

});

次に「views/auth/passwords/email.mst」というファイルをつくり中身を以下のようにします。

<html>
<head>
    <link href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div id="app">
    <div class="card-body">
        <div class="form-group row">
            <label for="email" class="col-md-4 col-form-label text-md-right">メールアドレス</label>
            <div class="col-md-6">
                <input type="email" class="form-control" v-model="email">
                <div class="alert alert-danger" v-if="error" v-text="error"></div>
            </div>
        </div>
        <div class="form-group row mb-0">
            <div class="col-md-6 offset-md-4">
                <button type="submit" class="btn btn-primary" @click="onSubmit">パスワード再発行リンクを送信する</button>
            </div>
        </div>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/vue@2.6.11"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.19.2/axios.min.js"></script>
    <script>

        new Vue({
            el: '#app',
            data: {
                email: '',
                error: ''
            },
            methods: {
                onSubmit() {

                    this.error = '';

                    axios.post('/password/email', { email: this.email })
                        .then(response => {

                            if(response.data.result) {

                                alert('入力されたメールアドレスにメッセージ送信しました。');

                            }

                        })
                        .catch(error => {

                            const errors = error.response.data.errors;

                            errors.every(error => {

                                this.error = error.msg;
                                return false;

                            });

                        });

                }
            }
        });

    </script>
</body>
</html>

中身としては、Vueaxiosを使ってメールアドレスをAjax送信しているだけです。

実際にブラウザで表示するとこのようになります。

パスワード再発行メールを送信する部分をつくる

続いて、送信されたメールアドレスに「パスワード再発行メール」を送信する部分をつくっていきます。

app.jsに以下のバリデーション・ルールとルートを追加してください。

// バリデーション・ルール
const passwordEmailValidationRules = [
  check('email')
    .not().isEmpty().withMessage('この項目は必須入力です。')
    .isEmail().withMessage('有効なメールアドレス形式で指定してください。')
    .custom((value, { req }) => {

      return User.findOne({
        where: {
          email: req.body.email
        }
      }).then(user => {

        if(!user) {

          throw new Error('このメールアドレスに一致するユーザーを見つけることが出来ませんでした。');

        }

      });

    })
];
app.post('/password/email', [passwordEmailValidationRules], (req, res) => {

  const errors = validationResult(req);

  if(!errors.isEmpty()) { // バリデーション失敗

    return res.status(422).json({ errors: errors.array() });

  }

  const email = req.body.email;
  const randomStr = Math.random().toFixed(36).substring(2, 38);
  const token = crypto.createHmac('sha256', APP_KEY)
    .update(randomStr)
    .digest('hex');
  const passwordResetUrl = APP_URL +'/password/reset/'+ token +'?email='+ encodeURIComponent(email);

  PasswordReset.findOrCreate({
    where: {
      email: email
    },
    defaults: {
      email: email,
      token: token,
      createdAt: new Date()
    }
  }).then(([passwordReset, created]) => {

    if(!created) {

      passwordReset.token = token;
      passwordReset.createdAt = new Date();
      passwordReset.save();

    }

    // メール送信
    transporter.sendMail({
      from: 'from@example.com',
      to: email,
      text: "以下のURLをクリックしてパスワードを再発行してください。\n\n"+ passwordResetUrl,
      subject: 'パスワード再発行メール',
    });
    res.json({ result: true });

  });

});

この中でやっていることは、以下のとおりです。

  1. 送信されたメールアドレスを持つユーザーがいるかチェック
  2. ユーザーが見つかったらトークンをつくって PasswordReset へ保存
  3. トークンとメールアドレスを含んだ「パスワード再発行URL」をつくる
  4. そのURLをメールで送信

新パスワードを設定するフォームをつくる

では次に、「パスワード再発行URL」をクリックしたときに表示されるフォームをつくっていきましょう。

app.jsに以下のルートを追加してください。

app.get('/password/reset/:token', (req, res) => {

  res.render('auth/passwords/reset', {
    token: req.params.token,
    email: req.query.email
  });

});

そして、「views/auth/passwords/reset.mst」というファイルをつくって、中身を以下のようにします。

<html>
<head>
    <link href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div id="app">
    <div class="card-body">
        <div class="alert alert-danger" v-if="errors.token" v-text="errors.token"></div>
        <div class="form-group row">
            <label for="email" class="col-md-4 col-form-label text-md-right">メールアドレス</label>
            <div class="col-md-6">
                <input type="email" class="form-control" v-model="params.email">
                <div class="alert alert-danger" v-if="errors.email" v-text="errors.email"></div>
            </div>
        </div>
        <div class="form-group row">
            <label for="password" class="col-md-4 col-form-label text-md-right">パスワード</label>
            <div class="col-md-6">
                <input type="password" class="form-control" v-model="params.password">
                <div class="alert alert-danger" v-if="errors.password" v-text="errors.password"></div>
            </div>
        </div>
        <div class="form-group row">
            <label for="password-confirm" class="col-md-4 col-form-label text-md-right">パスワード(確認)</label>
            <div class="col-md-6">
                <input type="password" class="form-control" v-model="params.passwordConfirmation">
                <div class="alert alert-danger" v-if="errors.passwordConfirmation" v-text="errors.passwordConfirmation"></div>
            </div>
        </div>
        <div class="form-group row mb-0">
            <div class="col-md-6 offset-md-4">
                <button type="submit" class="btn btn-primary" @click="onSubmit">登録する</button>
            </div>
        </div>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/vue@2.6.11"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.19.2/axios.min.js"></script>
    <script>

        new Vue({
            el: '#app',
            data: {
                params: {
                    token: '{{ token }}',
                    email: '{{ email }}',
                    password: '',
                    passwordConfirmation: ''
                },
                errors: {}
            },
            methods: {
                onSubmit() {

                    this.errors = {
                        token: '',
                        email: '',
                        password: '',
                        passwordConfirmation: ''
                    };

                    axios.post('/password/reset', this.params)
                        .then(response => {

                            if(response.data.result) {

                                alert('パスワードのリセットが完了しました。');

                            }

                        })
                        .catch(error => {

                            const errors = error.response.data.errors;

                            errors.forEach(error => {

                                const key = error.param;

                                if(this.errors[key] === '') {

                                    this.errors[key] = error.msg;

                                }

                            });

                        });

                }
            }
        });

    </script>
</body>
</html>

この中では、「メールアドレス」「パスワード」「パスワード(確認)」の3種類を入力してもらい、最終的にtokenも自動追加して送信されるようになっています。(Vuedataに直接メールアドレス、トークンを渡していることに注目してください)

新パスワードを設定する部分をつくる

では、最後に送信された新パスワードを設定する部分をつくっていきましょう。
ここでもapp.jsにバリデーション・ルールとルートを追加します。

// バリデーション・ルール
const passwordResetValidationRules = [
  check('email')
    .not().isEmpty().withMessage('この項目は必須入力です。')
    .isEmail().withMessage('有効なメールアドレス形式で指定してください。')
    .custom((value, { req }) => {

      return User.findOne({
        where: {
          email: req.body.email
        }
      }).then(user => {

        if(!user) {

          throw new Error('このメールアドレスに一致するユーザーを見つけることが出来ませんでした。');

        }

      });

    }),
  check('password')
    .not().isEmpty().withMessage('この項目は必須入力です。')
    .isLength({ min:8, max:25 }).withMessage('8文字から25文字にしてください。')
    .custom((value, { req }) => {

      if(req.body.password !== req.body.passwordConfirmation) {

        throw new Error('パスワード(確認)と一致しません。');

      }

      return true;

    })
];

app.post('/password/reset', [passwordResetValidationRules], (req, res) => {

  const errors = validationResult(req);

  if(!errors.isEmpty()) { // バリデーション失敗

    return res.status(422).json({ errors: errors.array() });

  }

  const email = req.body.email;
  const password = req.body.password;
  const token = req.body.token;

  PasswordReset.findOne({
    where: {
      email: email
    },
    include: [
      { model: User }
    ]
  }).then(passwordReset => {

    if(passwordReset &&
      passwordReset.token === token &&
      passwordReset.User) {

      const user = passwordReset.User;
      user.password = bcrypt.hashSync(password, bcrypt.genSaltSync(8));
      user.save();
      passwordReset.destroy();

      res.json({ result: true });

    } else {

      return res.status(422).json({
        errors: [
          {
            value: '',
            msg: 'このパスワードリセットトークンは無効です。',
            param: 'token',
            location: 'body'
          }
        ]
      });

    }

  });

});

この中での流れは以下のとおりです。

  1. メールアドレス、パスワードが正しいかチェック
  2. メールアドレスからパスワード再発行情報を取得
  3. もしデータが存在していれば、新しいパスワードを暗号化(ハッシュ化)。新パスワードとして保存

テストしてみる

では、実際にテストしてみましょう!

まず/password/resetにアクセスして、メールアドレスを送信します。

すると以下のアップが表示されます。

メールをチェックします。

メールに書かれているURLをクリックすると、以下のフォームが表示されるので「パスワード」「パスワード(確認)」を入力します。(メールアドレスは自動入力されます)

送信ボタンをクリックすると・・・

はい!
全てうまくいきました😊✨

ダウンロードする

今回実際に開発したソースコード一式を以下からダウンロードできます。

※ただし、パッケージのインストールやマイグレーション、テンプレート・エンジンの設定などはご自身で行っていただく必要があります。

Expressでパスワード再発行機能をつくる
開発のご依頼お待ちしております
開発のご依頼はこちらから: お問い合わせ
どうぞよろしくお願いいたします! by 九保すこひ

おわりに

ということで、今回は前回に引き続きExpressでパスワードの再発行機能をつくってみました。

結果として想像よりもたくさんやることがあったので、正直毎回こんなコードを直に書いていたらめんどうだなー💦 なんて思ってしまいました。

もういっそのこと、Expressの方で「ログイン」「ユーザー登録」「パスワード再発行」はパッケージ化しておいて、Laravelのようにインストールすればすぐ使えるようにすればいいのにな・・・なんて勝手に思ってしまいました。

(自分でやるべき??・・・うーん、、、検討してみます😅)

ではでは〜!

このエントリーをはてなブックマークに追加       follow us in feedly